Promise.all이 항상 빠른 선택은 아닌 이유
Promise.all이 항상 빠른 선택은 아닌 이유
독립적인 비동기 작업을 순서대로 기다리는 것보다 Promise.all로 함께 시작하면 전체 시간이 줄어든다. 하지만 작업 수가 데이터 크기만큼 늘어난다면 빠른 코드가 아니라 한꺼번에 부하를 만드는 코드가 될 수 있다.
- Promise.all은 작업을 실행하는 스케줄러가 아니라 여러 Promise의 완료를 기다리는 조합 도구다.
- 입력을 만들면서 이미 요청이 시작될 수 있다.
- 외부 API와 DB에는 처리 가능한 동시성 한도가 있다.
본문의 코드는 특정 저장소 구현을 복사하지 않고 개념을 설명하기 위해 재구성한 예시다. 이름·경로·수치는 실제 운영 정보와 무관하다.
목차
- #Promise.all이 해결하는 문제
- #무제한 병렬 처리의 비용
- #실패 처리 방식 선택
- #실패했다고 나머지 작업이 취소되지는 않는다
- #결과를 모두 보고 싶다면 allSettled
- #직렬, 전체 동시 실행, 제한 동시성 비교
- #판단할 때 묻는 질문
- #결론
- #관련 노트
Promise.all이 해결하는 문제
const [user, orders, notices] = await Promise.all([
getUser(userId),
getOrders(userId),
getNotices(userId),
]);
세 요청이 서로 의존하지 않을 때 좋은 사용이다. 가장 느린 작업의 시간에 가깝게 전체가 끝난다.
무제한 병렬 처리의 비용
await Promise.all(
userIds.map((id) => sendNotification(id))
);
userIds가 10개일 때는 괜찮아도 10만 개라면 연결 풀, 메모리, 외부 API rate limit을 동시에 압박한다. 동시성 제한이나 큐가 필요하다.
Promise.all에 넣었다고 CPU에서 모두 동시에 실행되는 것은 아니다. 다만 많은 I/O가 거의 같은 시점에 시작되어 하류 시스템에 동시 부하를 준다.
실패 처리 방식 선택
Promise.all은 하나가 reject되면 전체 await가 즉시 실패한다. 나머지 요청이 자동으로 취소되는 것은 아니다. 모든 결과를 수집해야 한다면 Promise.allSettled를 쓰고, 취소가 필요하면 AbortSignal을 함께 설계한다.
const results = await Promise.allSettled(tasks);
const failed = results.filter((item) => item.status === "rejected");
실패했다고 나머지 작업이 취소되지는 않는다
Promise.all은 입력 중 하나가 reject되면 반환한 Promise를 즉시 reject한다. 여기서 “즉시 실패한다”는 말은 이미 시작한 다른 작업을 취소한다는 뜻이 아니다.
function delayedJob(name, delay, shouldFail = false) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`${name}:finished`);
shouldFail ? reject(new Error(name)) : resolve(name);
}, delay);
});
}
await Promise.all([
delayedJob("first", 100),
delayedJob("second", 20, true),
delayedJob("third", 200),
]);
second가 20ms 뒤 실패하면 await는 예외를 던진다. 하지만 first와 third의 타이머는 계속 실행된다. HTTP 요청, 파일 업로드, DB 쿼리도 해당 API가 취소 신호를 지원하고 우리가 그 신호를 전달하지 않는 한 계속될 수 있다.
gantt
title Promise.all에서 한 작업이 실패한 경우
dateFormat X
axisFormat %Lms
section jobs
first :0, 100
second fails :0, 20
third :0, 200Promise.all이 실패한 직후 재시도하면 이전에 끝나지 않은 작업과 새 작업이 겹칠 수 있다. 쓰기 작업이라면 취소뿐 아니라 멱등성도 함께 필요하다.
결과를 모두 보고 싶다면 allSettled
대량 파일 검증처럼 일부 실패가 전체 중단을 의미하지 않는 작업은 Promise.allSettled가 더 잘 맞는다.
const results = await Promise.allSettled(
files.map((file) => validateFile(file)),
);
const summary = results.reduce(
(acc, result, index) => {
if (result.status === "fulfilled") {
acc.succeeded.push({ file: files[index], value: result.value });
} else {
acc.failed.push({ file: files[index], reason: result.reason.message });
}
return acc;
},
{ succeeded: [], failed: [] },
);
모든 결과를 기다리므로 메모리와 전체 대기 시간도 늘어날 수 있다. 결과가 매우 많다면 완료되는 대로 저장하거나 스트림·작업 큐를 사용하는 편이 낫다.
직렬, 전체 동시 실행, 제한 동시성 비교
| 방식 | 장점 | 단점 | 어울리는 상황 |
|---|---|---|---|
for ... await 직렬 |
단순하고 부하 예측이 쉬움 | 전체 시간이 길어짐 | 순서 의존, 적은 작업 |
Promise.all |
독립 작업의 지연을 겹침 | 동시 부하와 빠른 실패 | 개수가 작고 고정된 요청 |
Promise.allSettled |
모든 성공·실패 수집 | 취소하지 않고 끝까지 기다림 | 일괄 검증, 부분 성공 |
| 동시성 제한 | 처리량과 부하 균형 | 구현과 관측 필요 | 데이터 크기에 비례하는 작업 |
| 작업 큐 | 재시도·복구·분산 처리 | 운영 복잡도 증가 | 오래 걸리는 대량 작업 |
아래처럼 세 개의 독립된 화면 데이터 요청은 Promise.all의 좋은 예다.
const [profile, recentOrders, unreadCount] = await Promise.all([
profileClient.get(userId),
orderClient.listRecent(userId, { limit: 5 }),
notificationClient.countUnread(userId),
]);
반면 사용자 수만큼 메일을 보내는 코드는 입력 크기가 계속 커지므로 같은 방식으로 보면 안 된다.
// 예제이지만 운영에서는 피해야 하는 형태
await Promise.all(
users.map((user) => mailer.sendWelcome(user.email)),
);
판단할 때 묻는 질문
- 작업 수가 코드에 고정되어 있는가, 데이터 크기에 따라 늘어나는가?
- 하류 시스템의 연결 풀과 rate limit은 얼마인가?
- 하나가 실패하면 다른 작업도 의미가 없어지는가?
- 이미 시작한 작업을 취소할 수 있는가?
- 중간 성공을 보존해야 하는가?
- 프로세스가 재시작되어도 이어서 처리해야 하는가?
이 질문에 답하면 Promise.all을 쓸지보다 더 큰 실행 구조를 선택할 수 있다.
결론
Promise.all은 적은 수의 독립 작업을 묶을 때 강력하지만 데이터 전체를 무제한으로 실행하는 도구는 아니다. 처리량과 하류 한도를 기준으로 동시성을 결정한다.